feat(examples): add todolist connect example exercising the new streaming API#331
Merged
feat(examples): add todolist connect example exercising the new streaming API#331
Conversation
Ten proto files following the 1-1-1 best practice (one service per file,
one message per file):
- domain messages: todo_list, todo_item
- command requests: create_todo_list_request, add_todo_item_request,
mark_todo_item_as_done_request,
mark_todo_item_as_pending_request,
delete_todo_item_request
- query request: get_todo_list_request
- query response: get_todo_list_response
- service: todo_list_service
Design choices:
- Connect-only. No google.api.http annotations, no googleapis dep.
- Commands return google.protobuf.Empty; clients generate IDs.
- Queries return dedicated response messages.
Lint config allows empty RPC responses (the rest of the rules match
internal/user/proto/buf.yaml).
Vendored output of `buf generate` against the proto contracts. Stubs use the connectrpc.com/connect codegen (pinned in buf.gen.yaml): - one .pb.go per proto message file - one .connect.go for the full service Committing generated code keeps `go run ./examples/todolist` usable with just a Go toolchain, no buf dependency. Regenerate by running `buf generate proto` from the example root.
TodoList aggregate root with:
- domain events (WasCreated, ItemWasAdded, ItemMarkedAsDone,
ItemMarkedAsPending, ItemWasDeleted);
- Item child entity rehydrated via nested Apply;
- Create factory + AddItem / MarkItemAsDone / MarkItemAsPending /
DeleteItem methods, each emitting the corresponding event via
aggregate.RecordThat;
- repository.go with type aliases for aggregate.{Getter,Saver,Repository}.
Imports use the library's current layout (aggregate, event, version),
not the legacy core/ prefix.
Test uses aggregate.Scenario(typ) to cover the happy path end-to-end
(create list -> add item -> mark done -> delete).
Two command handlers:
- CreateTodoListHandler — creates a new TodoList via todolist.Create
and persists it through the repository.
- AddTodoListItemHandler — fetches an existing TodoList, calls AddItem,
saves the new version.
Both are struct-shaped handlers with a clock function and a repository
dependency. Unit tests use command.Scenario[Cmd, Handler] to cover:
- create: empty ID / empty title / empty owner / happy path;
- add item: list not found / duplicate item / empty title / happy path.
Imports target the library's current package layout; no core/ prefix.
GetTodoListHandler returns a TodoList by ID via an aggregate.Getter. Empty IDs short-circuit with todolist.ErrEmptyID, matching the domain's validation contract so the Connect layer can map it to InvalidArgument.
internal/protoconv: convert TodoList domain objects to their Protobuf
counterparts (one-way for now: proto <- domain).
internal/connect: Connect server implementation for TodoListService.
- CreateTodoList and AddTodoItem wired to their command handlers;
- GetTodoList wired to its query handler;
- MarkAsDone / MarkAsPending / Delete return CodeUnimplemented
(domain methods exist and are covered by the aggregate test;
adding command handlers for them is out of scope for this litmus
test and belongs in a follow-up).
Domain errors are mapped to Connect codes:
empty-field errors -> InvalidArgument
item already exists -> AlreadyExists
item/aggregate missing -> NotFound
everything else -> Internal
Error chains are propagated verbatim via fmt.Errorf(%w). Fine for an
example; a production service would sanitize.
Starts a Connect HTTP server backed by an in-memory event.Store. Wires
one query handler and two command handlers through an EventSourcedRepository.
Notes:
- gRPC health + gRPC reflection (v1 and v1alpha) are registered
alongside the service handler.
- h2c transport is used so the plain-HTTP example still speaks HTTP/2,
which Connect over gRPC requires.
- SIGINT / SIGTERM trigger graceful shutdown with a configurable
ShutdownTimeout; misconfiguration is surfaced via envconfig errors.
go.mod is a nested module with `replace` pointing at the repo root,
so the example tracks the library's in-progress changes without waiting
for a release.
Drops the `examples$` path exclusion from both linter and formatter
blocks in .golangci.yaml so examples get the same lint treatment as
the library itself. Generated code under examples/todolist/gen/ stays
excluded by virtue of the `generated: lax` setting recognising the
"Code generated by ... DO NOT EDIT" header.
Small fixes needed to pass the full lint gate in the new example:
- interface-implementation assertions marked //nolint:exhaustruct
(they intentionally use zero values; same convention as the
library's own event.Store and postgres.EventStore assertions);
- http.Server zero-value fields marked //nolint:exhaustruct
(stdlib struct with many optional fields);
- run() in main.go marked //nolint:funlen — linear wire-up is
easier to read as one function;
- whitespace tweaks to satisfy wsl_v5 inside the goroutine.
Note: golangci-lint run from repo root does not descend into
examples/todolist/ because it is a nested module. Lint for the
example is run with --config ../../.golangci.yaml from its root.
examples/todolist/README.md walks through what the example demonstrates, how to run it, and the deliberate design choices (Connect-only, commands return Empty, one-message-per-proto, in-memory store). Root README.md gains a small 'Examples' section pointing at the todolist example. Linking the example from the root helps discoverability for anyone landing on the library's pkg.go.dev page or GitHub landing.
Folds the seven request/response protos back into todo_list_service.proto alongside the service definition. Domain messages (todo_list, todo_item) stay in their own files, preserving the boundary between transport contracts and reusable domain types. Rationale: for a service of this size (6 RPCs, all unary), colocating requests and responses with the RPC that uses them reads better than strict one-message-per-file splitting. A reader opening todo_list_service.proto sees the full API contract without having to cross-reference eight files. Regenerated gen/ stubs accordingly; the connect stub is unchanged.
Ran `buf config migrate --module proto --buf-gen-yaml buf.gen.yaml`
from the example root, which:
- moved proto/buf.yaml up to buf.yaml at the module root and bumped
it to v2 schema (modules list now points at proto/);
- rewrote buf.gen.yaml to v2 schema (plugin entries moved from
`plugin:` to `remote:`; go_package_prefix moved under
managed.override);
- preserved all lint / breaking rule choices.
Also replaced the deprecated DEFAULT lint category with STANDARD per
buf's upgrade warning. `buf lint` now runs without any warnings.
README.md updated to reference the new `buf generate` command (no
longer needs an argument since the v2 config points at proto/).
Collapses internal/domain/todolist, internal/command, and internal/query
into a single internal/todolist package. All artifacts belonging to the
TodoList bounded context now live together; types use the package prefix
to stay readable at call sites:
old -> new
domain.TodoList, .Item, .ID, .ItemID -> todolist.*
command.CreateTodoList / Handler -> todolist.CreateCommand
todolist.CreateCommandHandler
command.AddTodoListItem / Handler -> todolist.AddItemCommand
todolist.AddItemCommandHandler
query.GetTodoList / Handler -> todolist.GetQuery
todolist.GetQueryHandler
Command and query test files moved with their code (renames preserved by
git; blame stays intact).
Transport layers stay separate:
- internal/connect keeps the Connect service implementation;
- internal/protoconv keeps domain->proto conversions.
Both update their imports and references to the new names. Doc.go files
from the old command/query packages are gone — the top-level todolist.go
now carries the package doc comment.
README updated to reflect the 'package by domain' convention.
slog has been in the stdlib since Go 1.21 and is the idiomatic choice
for structured logging in Go today. The example has modest logging
needs (startup + shutdown messages), so pulling in go.uber.org/zap as
a third-party dep no longer earns its keep.
Behaviour changes:
- logs go to stderr via slog.NewTextHandler at debug level;
- slog.SetDefault makes the instance available to any transitive
code that calls slog.Info / slog.Debug without wiring;
- no more deferred Sync() or platform-specific stderr-sync quirks.
go.mod drops go.uber.org/zap and its transitive chain
(go.uber.org/multierr).
Extends the package-by-domain reorganisation to the transport and conversion layers. internal/connect and internal/protoconv are gone; their content now lives in the same internal/todolist package as the aggregate, commands, and queries. Naming rides the package prefix: connect.TodoListServiceServer -> todolist.ConnectServiceHandler connect.parseUUID -> todolist.parseUUIDField (unexported) connect.mapCommandError -> todolist.mapCommandError (unexported) protoconv.FromTodoList -> todolist.ToProto The Connect service handler no longer needs a local import alias for aggregate / command / query types since they live in the same package; the file becomes markedly shorter. ToProto drops the trailing TodoList suffix: within the todolist package it is unambiguous. main.go loses the appconnect import alias; the server value is built directly as todolist.ConnectServiceHandler. README's 'package by domain' paragraph updated to reflect the fuller scope (transport and conversion now folded in too).
Three new commands complete the TodoList command surface: - MarkItemAsDoneCommand / MarkItemAsDoneCommandHandler - MarkItemAsPendingCommand / MarkItemAsPendingCommandHandler - DeleteItemCommand / DeleteItemCommandHandler Each handler loads the aggregate, dispatches the corresponding domain method (TodoList.MarkItemAsDone / .MarkItemAsPending / .DeleteItem), and persists the new version. No Clock dependency on these handlers since the underlying events do not carry timestamps. Each command ships with four scenario tests using command.Scenario[](): - list not found -> aggregate.ErrRootNotFound - item not found -> todolist.ErrItemNotFound - empty item ID -> todolist.ErrEmptyItemID - happy path -> asserts the expected Persisted event Shared fixture constants (testListTitle, testListOwner) moved to a new fixtures_test.go so the same strings aren't repeated across five test files (the previous add_item_command_test.go also picks them up).
Replaces the three Unimplemented methods on ConnectServiceHandler with
real implementations:
- MarkTodoItemAsDone dispatches MarkItemAsDoneCommand
- MarkTodoItemAsPending dispatches MarkItemAsPendingCommand
- DeleteTodoItem dispatches DeleteItemCommand
ConnectServiceHandler now carries six handler fields. Extracted a
parseListAndItemIDs helper to avoid duplicating the (todo_list_id,
todo_item_id) parsing dance across three otherwise identical methods.
Two small consequences of the struct growth:
- method receivers switched from value to pointer (the struct is now
112 bytes, past gocritic's hugeParam threshold);
- interface-implementation assertion switched from the value-receiver
form to (*ConnectServiceHandler)(nil), which also drops the
//nolint:exhaustruct directive.
main.go builds a *ConnectServiceHandler and wires in all three new
handler dependencies alongside Create / AddItem / Get.
README: drop the bullet noting these RPCs were unimplemented.
Previously, `make go.lint` and `make go.test` only covered the root module. `go test ./...` and `golangci-lint run` both stop at module boundaries, so the todolist example — a nested module with its own go.mod — was silently excluded from CI. A library change that broke the example could land green. Fix: the Makefile now discovers Go modules via `git ls-files '*go.mod'` and runs each target in every module. Currently that means the root library + `examples/todolist`; adding another example automatically picks it up with no Makefile change needed. Each nested module runs golangci-lint with an explicit `--config $GOLANGCI_LINT_CONFIG` pointing at the root .golangci.yaml (absolute path), so every module is linted with the same rule set as the library. Coverage: `go test -coverprofile=coverage.txt` writes a file per module (one at repo root, one at examples/todolist/coverage.txt). The test workflow now uploads both to Codecov; the list is inlined in the workflow with a comment to keep it in sync with the Makefile loop. Also added a `go.build` target that mirrors the same module iteration, useful for local development and for any future CI step that wants a cheaper "does it compile" gate. README updated with a short 'CI coverage' section so the guarantee is visible to future contributors.
Adds go.work at repo root declaring both modules (root library +
examples/todolist) as workspace members. The workspace delivers two
concrete benefits:
- IDE / gopls sees the library and example as one unit, so
go-to-definition, find-usages, and inline errors work across the
boundary while editing.
- examples/todolist/go.mod no longer needs the
`replace github.com/get-eventually/go-eventually => ../..` hack;
the workspace resolves the module locally.
It does NOT give us a single-invocation `go test ./...` across
modules: neither go nor golangci-lint span workspace members from a
single pattern (golang/vscode-go#2666). So the Makefile still iterates
— but the module list now comes from `go work edit -json` instead of
from a `git ls-files` heuristic, keeping go.work as the single
source of truth for 'what modules participate in this repo'.
Adding a new workspace member is now a single edit to go.work; the
Makefile (and therefore CI) picks it up automatically.
examples/todolist/go.mod's `require github.com/get-eventually/go-eventually`
line stays at v0.4.0 as a nominal floor. GOWORK=off would fall back to
resolving that version from the proxy — useful to know as a failure
mode if someone accidentally disables the workspace.
go.work.sum is committed, per Go 1.21+ convention.
771cb72 to
b01b14a
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #331 +/- ##
==========================================
+ Coverage 62.65% 67.62% +4.97%
==========================================
Files 39 37 -2
Lines 1727 1464 -263
==========================================
- Hits 1082 990 -92
+ Misses 583 416 -167
+ Partials 62 58 -4 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
The todolist example's coverage file contains the example's own source
files, which were dragging the project coverage metric down without
meaningfully reflecting library quality.
Tell Codecov to ignore:
- examples/** : the example's purpose is to be a litmus test for the
library API; its own code coverage is not a project KPI. Library
packages hit by the example's tests still count via the root
coverage upload.
- **/gen/** : generated protobuf/connect stubs have no meaningful
coverage story.
Also sets a 1%% threshold on both project and patch status checks so
tiny refactors do not flag PRs red over immaterial coverage drift.
Merged
ar3s3ru
added a commit
that referenced
this pull request
Apr 22, 2026
🤖 I have created a release *beep* *boop* --- <details><summary>0.4.1</summary> ## [0.4.1](v0.4.0...v0.4.1) (2026-04-22) ### ⚠ BREAKING CHANGES * replace channel-based event streaming with message.Stream iterator ([#330](#330)) ### Features * **examples:** add todolist connect example exercising the new streaming API ([#331](#331)) ([492525f](492525f)) * replace channel-based event streaming with message.Stream iterator ([#330](#330)) ([a96b37a](a96b37a)) ### Bug Fixes * **release-please:** enable bump-*-pre-major feature flags ([f8456ab](f8456ab)) * **release:** drop package-name from release-please config ([#328](#328)) ([6ae1f8e](6ae1f8e)) * rephrase docs ([1aa4425](1aa4425)) ### Documentation * **README:** add the How to Use section ([baddbb7](baddbb7)) ### Miscellaneous * release 0.4.1 ([f34e71c](f34e71c)) </details> --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Danilo Cianfrone <danilocianfr@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR resurrects the
feature/add-todolist-examplebranch (archived atarchive/feature/add-todolist-example) as a clean reapply on top offeat/event-stream-iterator(#330). The example serves as a real-world litmus test for the post-migration library API.It's composed of:
TodoListaggregate with a childItementity, built onaggregate.BaseRoot+aggregate.RecordThat.CreateTodoList,AddTodoListItem) implementingcommand.Handler[Cmd].GetTodoList) implementingquery.Handler[Q, R].aggregate.Scenario,command.Scenario.The command path loads the aggregate through
EventSourcedRepository.Get, which internally streams events through the newmessage.Stream[event.Persisted]iterator. That's the litmus test.Design choices (departures from the original branch)
google.api.httpannotations, nogoogleapisdep. Connect already speaks gRPC + gRPC-Web + Connect-over-HTTP.google.protobuf.Empty. Clients generate IDs (UUID) and pass them in the request. Idempotent on retry; no response-payload coupling.connectrpc.com/{connect,grpchealth,grpcreflect}(not the deprecatedbufbuild/connect-*module path).event.NewInMemoryStore()forpostgres.NewEventStore(...)inmain.goto get durability.go.modwithgo.workto resolve inter-module dependencies.